콘텐츠로 이동

17. 에러 처리와 디버깅

17.1 예외 처리

함수를 여러 번 반복 호출하다가 그중 일부 입력에서만 오류가 발생한다고 해봅시다. 아무런 조치도 하지 않으면 R은 오류가 발생한 순간 전체 실행을 그 자리에서 멈춰 버립니다. 데이터 100건을 순회하며 처리하는 도중 3번째 건에서 오류가 나면, 나머지 97건은 시도조차 해보지 못한 채 스크립트 전체가 중단되는 셈입니다.

숫자 벡터 3개와 문자 벡터 1개가 섞인 리스트의 합계를 각각 구하는 상황을 생각해 봅시다.

x_list <- list(1:5, c(2, 4, 6), c("a", "b", "c"), 10:15)

for (i in seq_along(x_list)) {
  cat("i =", i, ": 합계 =", sum(x_list[[i]]), "\n")
}
#> i = 1 : 합계 = 15 
#> i = 2 : 합계 = 12 
#> sum(x_list[[i]])에서 다음과 같은 에러가 발생했습니다: 인자의 'type' (character)이 올바르지 않습니다

세 번째 원소가 문자 벡터라 sum()이 오류를 내는 순간, 네 번째 원소(10:15)는 시도조차 되지 않고 반복문 전체가 멈춰 버립니다. try()로 오류가 날 수 있는 부분만 감싸면 오류가 난 건은 건너뛰고 나머지는 계속 처리할 수 있습니다.

for (i in seq_along(x_list)) {
  result <- try(sum(x_list[[i]]), silent = TRUE)
  if (inherits(result, "try-error")) {
    cat("i =", i, ": 오류 발생, 건너뜁니다.\n")
  } else {
    cat("i =", i, ": 합계 =", result, "\n")
  }
}
#> i = 1 : 합계 = 15 
#> i = 2 : 합계 = 12 
#> i = 3 : 오류 발생, 건너뜁니다.
#> i = 4 : 합계 = 75 

R은 오류·경고·메시지를 '조건(condition)'이라는 하나의 체계로 다룹니다. 조건이 발생했을 때 그냥 무시하고 다음으로 넘어갈지(try()), 조건의 종류(오류인지 경고인지)에 따라 각기 다르게 대응할지(tryCatch()), 대응은 하되 원래 계산을 이어갈지(withCallingHandlers())에 따라 함수를 골라 씁니다. 여기에 더해, 함수가 정상적으로 끝나든 오류로 중단되든 상관없이 항상 실행되어야 하는 뒷정리 작업이 있다면 on.exit()를 함께 씁니다.

try()

try(expr, silent = FALSE, outFile = getOption("try.outFile", default = stderr()))는 expr을 실행하다가 오류가 나더라도 프로그램 전체를 멈추지 않고, 오류 메시지를 담은 try-error 클래스의 객체를 대신 반환합니다.

  • expr : 실행할 표현식(오류가 날 수도 있는 코드).
  • silent : FALSE(기본값)이면 오류가 발생했을 때 그 메시지를 콘솔(또는 outFile)에 그대로 출력하고, TRUE이면 출력 없이 조용히 try-error 객체만 반환합니다. 반복문 안에서 쓸 때는 매번 오류 메시지가 찍히는 것을 막기 위해 보통 TRUE로 지정합니다.
  • outFile : silent = FALSE일 때 오류 메시지를 어디로 보낼지 지정합니다. 기본값은 표준오류(stderr)입니다.

오류가 나지 않으면 try()는 expr의 결과값을 그대로 돌려주므로, 위 예제처럼 result <- try(...) 형태로 받아 두고 inherits(result, "try-error")로 오류 여부를 판단하는 패턴이 일반적입니다. try-error 객체에는 오류 조건(condition) 자체도 속성으로 함께 담겨 있어, 오류 메시지만 따로 꺼내 쓸 수도 있습니다.

result <- try(sum(c("a", "b", "c")), silent = TRUE)
class(result)
#> [1] "try-error"

cat(result)
#> Error in sum(c("a", "b", "c")) : 인자의 'type' (character)이 올바르지 않습니다

cond <- attr(result, "condition")
conditionMessage(cond)
#> [1] "인자의 'type' (character)이 올바르지 않습니다"

tryCatch()

tryCatch(expr, ..., finally)는 expr을 실행하다가 특정 종류의 조건(오류·경고 등)이 발생했을 때, ...에 나열해 둔 핸들러(handler) 함수 중 조건의 클래스와 일치하는 것을 실행해 그 결과를 대신 반환합니다.

  • expr : 실행할 표현식.
  • ... : 조건클래스 = function(조건객체) {...} 형태의 핸들러들을 이름 붙여 나열합니다. 가장 흔히 쓰는 이름은 error(오류)와 warning(경고)이며, message(message() 조건)나 사용자가 직접 정의한 조건 클래스(아래 "더 알아보기" 참고)도 지정할 수 있습니다.
  • finally : 조건 발생 여부와 관계없이, expr 또는 핸들러 실행이 끝난 뒤 항상 마지막에 한 번 실행할 표현식입니다.

try()와 달리 tryCatch()는 조건의 종류에 따라 서로 다른 대응(대체값 반환, 로그 남기기 등)을 할 수 있습니다. 다만 한 가지 중요한 특징은, 핸들러가 실행되는 순간 expr의 나머지 계산은 전부 포기하고 tryCatch() 호출 지점으로 즉시 빠져나온다는 점입니다(이런 방식을 "exiting handler"라고 부릅니다).

safe_log <- function(x) {
  tryCatch({
    log(x)
  }, warning = function(w) {
    cat("경고 처리:", conditionMessage(w), "\n")
    NA
  }, error = function(e) {
    cat("오류 처리:", conditionMessage(e), "\n")
    NA
  })
}

safe_log(10)
#> [1] 2.302585

safe_log(-1)
#> 경고 처리: NaN이 생성되었습니다 
#> [1] NA

safe_log("a")
#> 오류 처리: 수학함수에 숫자가 아닌 인자가 전달되었습니다 
#> [1] NA

log(-1)은 계산 자체는 되지만(결과는 NaN) 경고를 발생시키고, log("a")는 아예 계산할 수 없어 오류를 발생시킵니다. tryCatch()는 각각을 warning·error 핸들러로 알맞게 잡아냅니다.

finally는 파일이나 연결(connection)을 열었다가 성공하든 실패하든 반드시 닫아야 하는 상황처럼, 뒷정리가 필요할 때 유용합니다.

divide <- function(x, y) {
  tryCatch({
    if (y == 0) stop("0으로 나눌 수 없습니다.")
    x / y
  }, error = function(e) {
    cat("오류:", conditionMessage(e), "\n")
    NA
  }, finally = {
    cat("계산 시도를 마쳤습니다. (x =", x, ", y =", y, ")\n")
  })
}

divide(10, 2)
#> 계산 시도를 마쳤습니다. (x = 10 , y = 2 )
#> [1] 5

divide(10, 0)
#> 오류: 0으로 나눌 수 없습니다. 
#> 계산 시도를 마쳤습니다. (x = 10 , y = 0 )
#> [1] NA

withCallingHandlers()

withCallingHandlers(expr, ...)는 tryCatch()와 인자 구성과 쓰임새는 비슷하지만, 핸들러를 실행한 뒤 expr의 나머지 계산을 포기하지 않고 원래 조건이 발생했던 지점으로 되돌아가 계속 진행할 수 있다는 점이 다릅니다(이런 방식을 "calling handler"라고 부릅니다).

  • expr : 실행할 표현식.
  • ... : tryCatch()와 마찬가지로 조건클래스 = function(조건객체) {...} 형태의 핸들러들입니다.

계산을 이어가려면 핸들러 안에서 invokeRestart("muffleWarning")(경고의 경우) 또는 invokeRestart("muffleMessage")(메시지의 경우)를 호출해, "이 조건은 처리했으니 다시 원래 자리로 돌아가라"고 R에 알려주어야 합니다. 이 재시작(restart) 지점은 warning()·message()가 미리 마련해 둔 것이라 바로 쓸 수 있지만, stop()으로 발생한 오류에는 이런 재시작 지점이 없어 withCallingHandlers()로도 계산을 되돌릴 수는 없습니다.

두 방식의 차이는 벡터에 결측 유발 값이 섞여 있을 때 뚜렷하게 드러납니다.

x <- c(4, 9, -1, 16, -4)

# tryCatch: 경고가 발생하는 순간 expr 전체가 즉시 중단됨
r1 <- tryCatch({
  sqrt(x)
}, warning = function(w) {
  cat("[tryCatch] 경고 발생, 전체 계산 중단:", conditionMessage(w), "\n")
  NA
})
#> [tryCatch] 경고 발생, 전체 계산 중단: NaN이 생성되었습니다 

r1
#> [1] NA
# withCallingHandlers: 경고를 처리한 뒤에도 계산이 이어짐
r2 <- withCallingHandlers({
  sqrt(x)
}, warning = function(w) {
  cat("[withCallingHandlers] 경고 발생, 계속 진행:", conditionMessage(w), "\n")
  invokeRestart("muffleWarning")
})
#> [withCallingHandlers] 경고 발생, 계속 진행: NaN이 생성되었습니다

r2
#> [1]   2   3 NaN   4 NaN

tryCatch()는 경고 하나 때문에 벡터 전체 계산 결과를 NA 하나로 날려버리지만, withCallingHandlers()는 경고를 기록만 해두고 계산 자체는 끝까지 진행해 NaN이 섞인 결과를 그대로 얻습니다. "경고가 몇 번 발생했는지 세어보고 싶지만 계산은 멈추고 싶지 않다" 같은 상황에 적합합니다.

on.exit()

on.exit(expr = NULL, add = FALSE, after = TRUE)는 현재 함수가 어떤 방식으로 끝나든(정상적으로 값을 반환하든, 오류로 중단되든) 함수를 빠져나가는 순간 항상 expr을 실행하도록 예약해 둡니다.

  • expr : 함수 종료 시 실행할 표현식. 생략하면(기본값 NULL) 예약된 것이 모두 취소됩니다.
  • add : FALSE(기본값)이면 새로 지정한 expr이 기존에 예약된 것을 덮어쓰고, TRUE이면 기존 것에 이어서 추가로 쌓입니다. 한 함수 안에서 on.exit()를 여러 번 쓰려면 두 번째 호출부터는 반드시 add = TRUE를 지정해야 합니다.
  • after : add = TRUE일 때, 새 expr을 기존 목록의 뒤(TRUE, 기본값)에 붙일지 앞(FALSE)에 붙일지 정합니다.

options()나 par()처럼 함수 안에서 전역 설정을 잠시 바꿨다가 함수가 끝나면 원래대로 되돌리고 싶을 때 특히 유용합니다.

print_precise <- function(x) {
  old_opt <- options(digits = 10)
  on.exit(options(old_opt))
  print(x)
}

options("digits")
#> $digits
#> [1] 7

print_precise(pi)
#> [1] 3.141592654

options("digits")
#> $digits
#> [1] 7

add = TRUE를 이용하면 여러 개의 뒷정리 작업을 순서대로 쌓아 둘 수 있습니다.

f <- function() {
  on.exit(cat("첫 번째 정리\n"))
  on.exit(cat("두 번째 정리\n"), add = TRUE)
  cat("본문 실행\n")
}
f()
#> 본문 실행
#> 첫 번째 정리
#> 두 번째 정리

tryCatch()의 finally와 비슷해 보이지만, on.exit()는 함수 전체에 대해 한 번만 선언해 두면 되고 함수 안 어디에서 오류가 나더라도(꼭 tryCatch()로 감싸지 않아도) 실행된다는 차이가 있습니다.

risky <- function() {
  on.exit(cat("정리 작업 실행\n"))
  stop("의도적 오류")
}
try(risky(), silent = TRUE)
#> 정리 작업 실행

다음 표는 세 예외 처리 함수의 핵심 차이를 정리한 것입니다.

함수 조건 구분 조건 발생 후 expr 계산 주로 쓰는 상황
try() 오류만 감지 그 지점에서 중단(exiting) 오류가 나도 반복문·스크립트 전체를 멈추지 않게 하고 싶을 때
tryCatch() 클래스별 핸들러 지정 핸들러 실행 후 즉시 중단(exiting) 오류·경고에 따라 각기 다른 대체값·로그를 남기고 싶을 때
withCallingHandlers() 클래스별 핸들러 지정 invokeRestart()로 복구 시 계속 진행(calling) 경고 등을 처리하면서도 원래 계산을 끝까지 진행하고 싶을 때

17.2 경고·오류 발생

직접 만든 함수에 잘못된 값이 들어왔을 때, 이를 어떻게 알려야 할까요? 무조건 조용히 넘어가면 이후 계산이 엉뚱한 결과를 내고도 아무도 눈치채지 못할 위험이 있고, 반대로 사소한 것까지 전부 실행을 중단시키면 배치 작업 하나가 예외적인 값 하나 때문에 전체 파이프라인을 멈춰 세우는 과잉 대응이 됩니다.

나이를 입력받아 검증하는 함수를 만든다고 해봅시다. "숫자형이 아닌 값"과 "음수"와 "100세 초과"는 심각도가 서로 다릅니다. 숫자형이 아니면 뒤의 계산 자체가 불가능하니 즉시 멈춰야 하지만, 음수는 계산은 가능하되 사용자에게 확인을 권할 문제이고, 100세 초과는 오류라기보다 단순히 기록해 둘 만한 특이 사항입니다.

check_age <- function(age) {
  if (!is.numeric(age)) {
    stop("age는 숫자형이어야 합니다.")
  }
  if (age < 0) {
    warning("나이가 음수입니다. 입력을 확인하세요.")
  }
  if (age > 100) {
    message("나이가 100세를 초과했습니다. 특이 케이스로 기록합니다.")
  }
  invisible(age)
}

일반화: R은 이 심각도를 세 단계로 나누어 각각 다른 함수로 제공합니다.

함수 심각도 실행 흐름 기본 출력 위치
stop() 오류(error) 즉시 중단, 그 함수(또는 스크립트) 전체가 더 진행되지 않음 표준오류(stderr)
warning() 경고(warning) 계속 진행하되, 함수가 최상위로 반환된 뒤 경고 메시지가 모아서 출력됨(기본값) 표준오류(stderr)
message() 정보(message) 계속 진행, 그 자리에서 즉시 출력 표준오류(stderr)

stop()

stop(..., call. = TRUE, domain = NULL)는 실행을 그 자리에서 즉시 중단시키고 오류(error) 조건을 발생시킵니다.

  • ... : 오류 메시지로 표시할 문자열(들). 여러 개를 넘기면 이어붙여 하나의 메시지가 됩니다. 문자열 대신 조건 객체(condition object)를 직접 넘길 수도 있습니다(아래 "더 알아보기" 참고).
  • call. : TRUE(기본값)이면 오류가 발생한 함수 호출을 "Error in 함수호출 : 메시지" 형태로 함께 표시하고, FALSE이면 호출 정보 없이 메시지만 "Error: 메시지" 형태로 표시합니다.
  • domain : 메시지를 다국어로 번역할 때 쓰는 인자로, 일반적인 경우에는 기본값(NULL)을 그대로 둡니다.
check_age(25)
check_age("스물다섯")
#> check_age("스물다섯")에서 다음과 같은 에러가 발생했습니다: age는 숫자형이어야 합니다.

check_age(25)는 어떤 조건에도 걸리지 않아 invisible(age)가 반환될 뿐 화면에는 아무것도 출력되지 않습니다. call. = FALSE를 지정하면 최종 사용자에게 R 함수 이름까지 노출할 필요가 없는 도구를 만들 때 메시지를 더 깔끔하게 보여줄 수 있습니다.

f <- function(x) {
  if (!is.numeric(x)) stop("x는 숫자형이어야 합니다.", call. = FALSE)
  x
}
f("a")
#> 에러: x는 숫자형이어야 합니다.

warning()

warning(..., call. = TRUE, immediate. = FALSE, noBreaks. = FALSE, domain = NULL)는 실행을 중단시키지 않으면서 경고(warning) 조건을 발생시킵니다.

  • ..., call., domain : stop()과 같은 역할입니다.
  • immediate. : FALSE(기본값)이면 경고를 일단 모아 두었다가 함수가 최상위로 반환된 직후에 한꺼번에 출력하고, TRUE이면 warning()이 호출되는 그 즉시 화면에 출력합니다.
  • noBreaks. : 경고 메시지를 출력할 때 줄바꿈을 넣지 않도록 강제하는 옵션으로, 거의 쓰이지 않습니다.
check_age(-5)
#> 경고메시지(들):
#> check_age(-5)에서 : 나이가 음수입니다. 입력을 확인하세요.

경고는 함수 실행 자체는 막지 않지만, 기본적으로는 계산이 다 끝난 뒤에야 화면에 나타납니다. 오래 걸리는 반복 작업 중간에 경고가 났다는 사실을 바로 알고 싶다면 immediate. = TRUE를 씁니다.

f <- function() {
  warning("즉시 출력되는 경고", immediate. = TRUE)
  cat("경고 이후에도 코드는 계속 실행됩니다.\n")
}
f()
#> f()에서 경고가 발생했습니다 : 즉시 출력되는 경고
#> 경고 이후에도 코드는 계속 실행됩니다.

이미 발생을 알고 있는 경고를 굳이 보고 싶지 않다면 suppressWarnings()로 감싸 조용히 무시할 수 있습니다(suppressWarnings(expr, classes = "warning")).

message()

message(..., domain = NULL, appendLF = TRUE)는 오류도 경고도 아닌, 단순한 정보성 메시지를 표준오류(stderr)로 즉시 출력합니다.

  • ... : 출력할 문자열(들).
  • domain : stop()·warning()과 같은 역할입니다.
  • appendLF : TRUE(기본값)이면 메시지 끝에 줄바꿈을 자동으로 붙이고, FALSE이면 붙이지 않습니다.
check_age(120)
#> 나이가 100세를 초과했습니다. 특이 케이스로 기록합니다.

cat()과 비슷해 보이지만, message()는 표준출력(stdout)이 아니라 표준오류(stderr)로 나간다는 점이 다릅니다. 그 덕분에 suppressMessages()로 다른 출력에 영향을 주지 않고 메시지만 골라서 끌 수 있고, 배치 스크립트에서는 실제 결과(stdout)와 진행 상황 안내(stderr)를 분리해 다룰 수 있습니다.

suppressMessages(check_age(120))   # 메시지가 나오지 않음
suppressWarnings(check_age(-5))    # 경고가 나오지 않음
cat("정상적으로 마무리\n")
#> 정상적으로 마무리

stopifnot()

stopifnot(..., exprs, exprObject, local = TRUE)는 ...에 나열한 논리 조건이 모두 TRUE인지 검사하고, 하나라도 FALSE이면 그 자리에서 오류를 발생시킵니다. 함수 맨 앞에서 입력값을 검증하는 코드를 if () stop() 여러 줄 대신 한 번에 짧게 쓰고 싶을 때 유용합니다.

  • ... : 검사할 논리 조건들. 이름을 붙이지 않으면 "조건식 is not TRUE"라는 기본 오류 메시지가 쓰이고, "메시지" = 조건 형태로 이름을 붙이면(R 3.5 이상) 그 이름이 그대로 오류 메시지가 됩니다.
  • exprs, exprObject : 여러 조건을 {} 블록이나 별도의 표현식 객체로 한꺼번에 넘기고 싶을 때 쓰는 고급 인자로, 보통은 ...만으로 충분합니다.
  • local : 조건식을 평가할 환경을 지정하는 고급 옵션으로, 기본값(호출된 위치)을 그대로 쓰는 경우가 대부분입니다.

이름을 붙이지 않으면 R이 자동으로 생성한 다소 딱딱한 메시지가 나옵니다.

f <- function(x) {
  stopifnot(is.numeric(x))
  x * 2
}
f("a")
#> f("a")에서 다음과 같은 에러가 발생했습니다 : is.numeric(x) is not TRUE

이름을 붙이면 사용자에게 더 친절한 메시지를 보여줄 수 있습니다.

validate_scores <- function(scores) {
  stopifnot(
    "scores는 숫자형 벡터여야 합니다." = is.numeric(scores),
    "scores에는 결측값이 없어야 합니다." = !anyNA(scores)
  )
  mean(scores)
}

validate_scores(c(80, 90, 100))
#> [1] 90

validate_scores(c(80, NA, 100))
#> validate_scores(c(80, NA, 100))에서 다음과 같은 에러가 발생했습니다:
#>   scores에는 결측값이 없어야 합니다.

더 알아보기: 나만의 조건 클래스 만들기

tryCatch()는 핸들러 이름으로 error·warning뿐 아니라 사용자가 직접 정의한 클래스 이름도 쓸 수 있습니다. 조건 객체는 structure()로 message(메시지)와 call(호출 정보)을 담은 리스트에 원하는 클래스를 부여해 직접 만들 수 있습니다. 이때 클래스 벡터의 마지막에는 반드시 "condition"을, 오류로 취급하려면 그 앞에 "error"를 포함시켜야 합니다.

myCondition <- function(msg, class) {
  structure(
    class = c(class, "condition"),
    list(message = msg, call = sys.call(-1))
  )
}

validate_positive <- function(x) {
  if (x <= 0) {
    stop(myCondition(paste0(x, "은(는) 양수가 아닙니다."),
                      c("negativeValueError", "error")))
  }
  sqrt(x)
}

tryCatch(
  validate_positive(-4),
  negativeValueError = function(e) {
    cat("사용자 정의 오류 처리:", conditionMessage(e), "\n")
    NA
  }
)
#> 사용자 정의 오류 처리: -4은(는) 양수가 아닙니다. 
#> [1] NA

negativeValueError는 "error"를 상속하도록 만들었기 때문에, 이 클래스를 모르는 코드라도 일반 error 핸들러로 똑같이 잡아낼 수 있습니다.

tryCatch(
  validate_positive(-9),
  error = function(e) {
    cat("일반 오류 처리로도 포착됨:", conditionMessage(e), "\n")
    NA
  }
)
#> 일반 오류 처리로도 포착됨: -9은(는) 양수가 아닙니다. 
#> [1] NA

validate_positive(16)
#> [1] 4

큰 프로젝트에서 "입력 오류"와 "네트워크 오류"처럼 원인이 다른 오류를 호출하는 쪽에서 서로 다르게 처리하고 싶을 때 이 방식이 특히 유용합니다.

17.3 디버깅 도구

코드가 예상과 다르게 동작할 때 가장 흔한 대응은 의심되는 지점마다 print()나 cat()을 끼워 넣어 중간값을 확인하는 것입니다. 하지만 이 방법은 확인이 끝나면 다시 코드를 원상복구해야 하고, 지우는 것을 깜빡하기도 쉬우며, 한 번에 한 시점의 값만 볼 수 있어 여러 변수의 관계를 종합적으로 살펴보기에는 불편합니다.

BMI를 계산하는 함수 내부에서 실제로 어떤 값이 어떤 순서로 계산되는지, 코드를 고치지 않고 한 줄씩 직접 눈으로 확인하고 싶다고 해봅시다. 코드 중간에 browser()를 삽입해 두면, 실행이 그 지점에 도달했을 때 R이 자동으로 멈추고 대화형 디버깅 세션으로 들어갑니다.

calc_bmi <- function(weight, height) {
  browser()
  bmi <- weight / (height^2)
  bmi
}
calc_bmi(68, 1.7)
#> Called from: calc_bmi(68, 1.7)

이 시점에서 콘솔 프롬프트가 Browse[1]>로 바뀌며 실행이 멈춥니다. 자세한 사용법은 아래 browser() 항목에서 이어서 다룹니다.

상황에 따라 쓸 도구가 달라집니다. 오류가 이미 발생한 뒤 사후에 원인을 추적하려면 traceback()을, 코드 안 특정 줄에서 멈추고 싶으면 browser()를, 코드를 건드리지 않고 함수 진입 시점부터 살펴보고 싶으면 debug()를, 값을 멈춰서 보기보다 호출 자체를 기록하고 싶으면 trace()를 씁니다.

traceback()

traceback(x = NULL, max.lines)는 가장 최근에 발생한(아직 살펴보지 않은) 오류의 호출 스택(call stack)을 보여줍니다.

  • x : 직접 지정한 호출 목록을 보여주고 싶을 때 사용합니다. 생략하면(기본값 NULL) R이 마지막 오류 시점에 자동으로 기억해 둔 .Traceback을 사용합니다.
  • max.lines : 각 호출을 몇 줄까지 표시할지 제한합니다. 기본값은 제한 없음입니다.

오류 메시지 한 줄만으로는 "어떤 함수가 어떤 함수를 거쳐 호출되다가" 문제가 생겼는지 알기 어려운 경우가 많습니다. R은 오류가 날 때마다 그 시점의 호출 스택을 조용히 기억해 두는데, traceback()은 이를 거꾸로(가장 안쪽 호출부터) 보여줍니다.

f <- function(x) g(x)
g <- function(x) h(x)
h <- function(x) {
  if (x < 0) stop("x는 음수가 될 수 없습니다.")
  sqrt(x)
}

f(-4)
#> h(x)에서 다음과 같은 에러가 발생했습니다: x는 음수가 될 수 없습니다.

traceback()
#> 4: stop("x는 음수가 될 수 없습니다.") at #2
#> 3: h(x) at #1
#> 2: g(x) at #1
#> 1: f(-4)

번호가 가장 작은(1번) 것이 최초 호출이고, 번호가 커질수록 더 안쪽으로 들어간 호출입니다. f() → g() → h() 순으로 호출되다가 h() 안의 stop()에서 오류가 발생했음을 한눈에 알 수 있습니다. RStudio를 쓴다면 오류가 나는 즉시 콘솔에 뜨는 "Show Traceback" 링크로 같은 정보를 확인할 수 있습니다.

browser()

browser(text = "", condition = NULL, expr = TRUE, skipCalls = 0L)는 함수 코드 안에 삽입해 두면, 실행이 그 줄에 도달했을 때 잠시 멈추고 대화형 디버깅 세션(browser 모드)으로 진입시킵니다.

  • text : 디버깅 세션에 함께 표시할 안내 문구(생략 가능).
  • condition : 이 지점에서 멈출 조건. 지정하지 않으면(기본값 NULL) 항상 멈춥니다.
  • expr : FALSE로 지정하면 이 browser()가 동작하지 않도록 잠시 꺼 둘 수 있습니다(코드에서 지우지 않고 비활성화만 하고 싶을 때 유용).
  • skipCalls : 호출 스택 표시에서 무시할 바깥쪽 호출 개수를 지정하는 고급 옵션입니다.

browser 세션에서는 일반 R 콘솔처럼 아무 변수나 입력해 값을 확인할 수 있고, 다음과 같은 몇 가지 전용 명령어를 함께 씁니다.

명령어 동작
n (또는 Enter) 다음 한 줄 실행(next)
s 다음 줄을 실행하되, 함수 호출이면 그 내부로 들어감(step into)
f 현재 반복문 또는 함수를 끝까지 실행하고 빠져나감(finish)
c 다음 중단점(또는 끝)까지 계속 실행(continue)
Q 디버깅을 중단하고 R 콘솔 최상위로 즉시 복귀
그 외 입력 일반 R 표현식으로 평가되어 값이 출력됨(변수 확인 등)

앞서 본 calc_bmi()에서 n을 두 번 눌러 한 줄씩 진행하고, bmi를 직접 입력해 값을 확인한 뒤 c로 나머지를 실행하는 흐름은 다음과 같습니다.

Browse[1]> n
debug at #3: bmi <- weight/(height^2)
Browse[2]> n
debug at #4: bmi
Browse[2]> bmi
[1] 23.52941
Browse[2]> c
[1] 23.52941

n을 처음 입력한 시점에는 아직 bmi <- weight/(height^2)가 실행되기 전(이 줄이 다음에 실행될 줄로 표시됨)이라는 점에 주의해야 합니다. 그래서 이 시점에 bmi를 입력하면 아직 존재하지 않는 변수라는 오류가 납니다. 한 번 더 n을 눌러 그 줄이 실제로 실행된 뒤에야 bmi 값을 확인할 수 있습니다.

debug() / debugonce()

debug(fun, text = "", condition = NULL, signature = NULL)는 함수 fun을 디버그 모드로 전환해, 그 함수가 호출될 때마다 첫 줄에서 자동으로 browser()가 걸린 것처럼 멈추게 합니다. debugonce(fun, ...)은 인자 구성은 같지만 딱 한 번 호출될 때만 멈추고, 그다음부터는 디버그 모드가 자동으로 해제됩니다.

  • fun : 디버그 모드로 전환할 함수(함수 이름 또는 함수 객체).
  • text, condition : browser()와 같은 역할입니다.
  • signature : S4 제네릭 함수의 특정 메서드만 디버그 모드로 전환하고 싶을 때 지정하는 고급 옵션입니다(19.3절 S4 클래스 참고).

browser()는 코드를 직접 수정해 원하는 줄에 삽입해야 하지만, debug()는 코드를 전혀 건드리지 않고도 함수 진입 시점부터 한 줄씩 살펴볼 수 있다는 점이 다릅니다. debug()로 켠 디버그 모드는 undebug(fun)으로 직접 꺼야 하는데, 이를 잊기 쉬우므로 한 번만 확인하면 되는 경우에는 debugonce()를 쓰는 편이 안전합니다.

calc_bmi <- function(weight, height) {
  bmi <- weight / (height^2)
  bmi
}

debugonce(calc_bmi)
calc_bmi(68, 1.7)
#> debugging in: calc_bmi(68, 1.7)
#> debug at #1: {
#>     bmi <- weight/(height^2)
#>     bmi
#> }

n을 두 번, c를 한 번 입력하면 다음과 같이 진행되고, 함수가 정상적으로 반환된 뒤에는 디버그 모드가 자동으로 해제됩니다.

Browse[2]> n
debug at #2: bmi <- weight/(height^2)
Browse[2]> n
debug at #3: bmi
Browse[2]> c
exiting from: calc_bmi(68, 1.7)
[1] 23.52941
calc_bmi(60, 1.6)   # debugonce()는 1회성이므로 이번에는 멈추지 않음
#> [1] 23.4375

trace()

trace(what, tracer, exit, at, print, signature, where = topenv(parent.frame()), edit = FALSE)는 함수 what의 소스 코드를 실제로 바꾸지 않고도, 호출될 때(또는 원한다면 특정 줄에 도달했을 때) 지정한 코드(tracer)를 끼워 넣어 함께 실행하게 합니다.

  • what : 추적할 함수(이름 또는 함수 객체).
  • tracer : 함수 진입 시 실행할 표현식. quote()로 감싼 R 표현식을 넘깁니다.
  • exit : 함수가 반환되는 시점에 실행할 표현식(생략 가능).
  • at : 함수 본문의 몇 번째 단계에서 tracer를 실행할지 지정하는 고급 옵션(생략하면 진입 시점).
  • print : TRUE(기본값)이면 추적이 실행될 때마다 "Tracing ... on entry" 같은 안내 메시지를 함께 출력합니다.
  • signature : debug()와 마찬가지로 S4 메서드 단위로 추적하고 싶을 때 사용합니다.
  • where : 추적을 적용할 환경을 지정하는 고급 옵션입니다.
  • edit : TRUE로 지정하면 편집기를 열어 추적 코드를 직접 작성할 수 있습니다.

browser()·debug()는 실행을 실제로 멈추지만, trace()는 멈추지 않고도 함수가 호출될 때마다 로그를 남기거나 인자값을 기록하는 용도로 특히 유용합니다. 다 사용한 뒤에는 untrace()로 되돌립니다.

calc_bmi <- function(weight, height) {
  weight / (height^2)
}

trace(calc_bmi, tracer = quote(
  cat("calc_bmi() 호출됨: weight =", weight, ", height =", height, "\n")
))

calc_bmi(68, 1.7)
#> Tracing calc_bmi(68, 1.7) on entry 
#> calc_bmi() 호출됨: weight = 68 , height = 1.7 
#> [1] 23.52941

calc_bmi(55, 1.6)
#> Tracing calc_bmi(55, 1.6) on entry 
#> calc_bmi() 호출됨: weight = 55 , height = 1.6 
#> [1] 21.48437

untrace(calc_bmi)
calc_bmi(70, 1.75)   # untrace() 이후에는 추적 메시지가 나오지 않음
#> [1] 22.85714

더 알아보기: 대화형 세션이 아닌 스크립트에서 사후 디버깅하기

browser()·debug()·trace()는 모두 사람이 즉석에서 명령을 입력할 수 있는 대화형(interactive) 세션을 전제로 합니다. 그런데 서버에서 Rscript로 예약 실행되는 배치 스크립트는 오류가 나는 순간 아무도 지켜보고 있지 않은 경우가 많습니다. 이럴 때는 오류 발생 시점의 호출 스택 전체를 파일로 저장해 두었다가, 나중에 사람이 편한 시간에 불러와 사후(post-mortem)로 디버깅할 수 있습니다.

dump.frames(dumpto = "last.dump", to.file = FALSE, include.GlobalEnv = FALSE)는 현재 호출 스택의 모든 환경(그 안의 변수 상태까지)을 저장합니다. to.file = TRUE로 지정하면 dumpto에 지정한 이름으로 .rda 파일에 저장되어, R을 껐다 켜도 나중에 다시 불러올 수 있습니다.

배치 스크립트 맨 앞에 다음처럼 options(error = ...)를 지정해 두면, 스크립트 어디에서든 처리되지 않은 오류가 발생하는 즉시 자동으로 덤프가 저장됩니다.

# batch_script.R
options(error = quote(dump.frames(to.file = TRUE)))

f <- function(x) g(x)
g <- function(x) {
  if (x < 0) stop("x는 0보다 커야 합니다.")
  sqrt(x)
}

f(-9)
#> g(x)에서 다음과 같은 에러가 발생했습니다: x는 0보다 커야 합니다.

스크립트가 중단된 자리에는 last.dump.rda 파일이 남습니다. 이 파일을 나중에 불러와 debugger()로 열어 보면, 오류 당시의 호출 스택을 메뉴 형태로 다시 확인하고 원하는 환경을 골라 그 안의 변수 상태까지 들여다볼 수 있습니다.

load("last.dump.rda")
debugger(last.dump)
#> 메시지:  g(x)에서 다음과 같은 에러가 발생했습니다: x는 0보다 커야 합니다.
#> 사용가능한 environments가 다음의 호출을 가집니다:
#> 1: f(-9)
#> 2: #1: g(x)
#> 3: #2: stop("x는 0보다 커야 합니다.")
#> 
#> 환경번호를 입력하시거나, 종료하기 위해서는 0을 입력하세요
#> 선택:

여기서 환경 번호(예: 2)를 입력하면 그 시점의 browser() 세션으로 들어가 g() 내부의 x 값 등을 그대로 확인할 수 있고, 0을 입력하면 종료됩니다.

대화형 세션 안에서라면 options(error = recover)로 지정해 두는 것으로 충분합니다. 오류가 날 때마다 파일 저장 없이 바로 이 환경 선택 메뉴(recover())가 뜹니다. 다만 recover() 역시 대화형 세션이 있어야 하므로, 사람이 없는 배치 스크립트에는 dump.frames() 방식이 적합합니다.

다음 표는 상황에 따라 어떤 디버깅 도구를 고르면 좋을지 정리한 것입니다.

상황 추천 도구
오류가 이미 발생했고, 어떤 호출 경로를 거쳤는지 사후에 확인하고 싶다 traceback()
함수 코드 중 특정 줄에서 정확히 멈추고 싶다 그 줄에 browser() 삽입
함수 코드는 건드리지 않고 진입 시점부터 살펴보고 싶다 debug() / debugonce()
값 확인보다 함수가 언제·어떤 인자로 호출되는지 기록하고 싶다 trace()
대화형 세션이 아닌 배치 스크립트에서의 오류에 대비하고 싶다 options(error = quote(dump.frames(to.file = TRUE))) + debugger()